| 1 | import Link from "next/link"; |
| 2 | import { SiteFooter } from "@/components/site-footer"; |
| 3 | import { PhotoGallery } from "@/components/photo-gallery"; |
| 4 | import { notFound, redirect } from "next/navigation"; |
| 5 | import { revalidatePath } from "next/cache"; |
| 6 | import { serverCaller } from "@/lib/trpc/server"; |
| 7 | import { saveUpload } from "@/lib/uploads"; |
| 8 | import { cityTimeZone, fmtEventTime } from "@/lib/format"; |
| 9 | |
| 10 | export const dynamic = "force-dynamic"; |
| 11 | |
| 12 | const TYPE_LABEL: Record<string, string> = { |
| 13 | set: "Set", |
| 14 | weekender: "Weekender", |
| 15 | class: "Class", |
| 16 | workshop: "Workshop", |
| 17 | competition: "Competition", |
| 18 | special: "Special event", |
| 19 | social: "Social", |
| 20 | }; |
| 21 | |
| 22 | function money(cents: number) { |
| 23 | return cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)}`; |
| 24 | } |
| 25 | |
| 26 | |
| 27 | function stamp(d: Date | null) { |
| 28 | if (!d) return ""; |
| 29 | return new Date(d) |
| 30 | .toLocaleString("en-US", { |
| 31 | month: "short", |
| 32 | day: "numeric", |
| 33 | hour: "numeric", |
| 34 | minute: "2-digit", |
| 35 | }) |
| 36 | .toUpperCase(); |
| 37 | } |
| 38 | |
| 39 | export default async function EventPage({ |
| 40 | params, |
| 41 | }: { |
| 42 | params: Promise<{ slug: string; id: string }>; |
| 43 | }) { |
| 44 | const { slug, id } = await params; |
| 45 | if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { |
| 46 | notFound(); |
| 47 | } |
| 48 | const caller = serverCaller(); |
| 49 | const city = await caller.city.bySlug({ slug }); |
| 50 | if (!city) notFound(); |
| 51 | |
| 52 | const event = await caller.event.byId({ id }); |
| 53 | if (!event) notFound(); |
| 54 | |
| 55 | const tz = cityTimeZone(city.state); |
| 56 | |
| 57 | const ticketTypes = await caller.ticketType.list({ eventId: id }); |
| 58 | const featured = |
| 59 | event.isPromoted && |
| 60 | (!event.boostedUntil || new Date(event.boostedUntil) > new Date()); |
| 61 | |
| 62 | const comments = await caller.comment.list({ |
| 63 | subjectType: "event", |
| 64 | subjectId: id, |
| 65 | }); |
| 66 | |
| 67 | const photos = await caller.eventPhoto.list({ eventId: id }); |
| 68 | const eventVendors = await caller.vendor.listForEvent({ |
| 69 | cityId: city.id, |
| 70 | eventId: id, |
| 71 | }); |
| 72 | |
| 73 | async function reserve(formData: FormData) { |
| 74 | "use server"; |
| 75 | const ticketTypeId = String(formData.get("ticketTypeId") ?? ""); |
| 76 | const quantity = Math.max(1, Math.min(10, Number(formData.get("quantity") ?? 1))); |
| 77 | if (!ticketTypeId) return; |
| 78 | await serverCaller().ticket.reserve({ ticketTypeId, quantity }); |
| 79 | redirect("/tickets"); |
| 80 | } |
| 81 | |
| 82 | async function addTicketType(formData: FormData) { |
| 83 | "use server"; |
| 84 | const name = String(formData.get("name") ?? "").trim(); |
| 85 | const dollars = Number(formData.get("price") ?? 0); |
| 86 | const capRaw = String(formData.get("capacity") ?? "").trim(); |
| 87 | if (!name) return; |
| 88 | await serverCaller().ticketType.create({ |
| 89 | eventId: id, |
| 90 | name, |
| 91 | priceCents: Math.max(0, Math.round(dollars * 100)), |
| 92 | capacity: capRaw ? Math.max(1, Number(capRaw)) : undefined, |
| 93 | }); |
| 94 | revalidatePath(`/city/${slug}/event/${id}`); |
| 95 | } |
| 96 | |
| 97 | async function boost() { |
| 98 | "use server"; |
| 99 | await serverCaller().event.boost({ id, days: 14 }); |
| 100 | revalidatePath(`/city/${slug}/event/${id}`); |
| 101 | } |
| 102 | |
| 103 | async function addComment(formData: FormData) { |
| 104 | "use server"; |
| 105 | const body = String(formData.get("body") ?? "").trim(); |
| 106 | if (!body) return; |
| 107 | await serverCaller().comment.create({ |
| 108 | subjectType: "event", |
| 109 | subjectId: id, |
| 110 | body, |
| 111 | }); |
| 112 | revalidatePath(`/city/${slug}/event/${id}`); |
| 113 | } |
| 114 | |
| 115 | async function submitPhoto(formData: FormData) { |
| 116 | "use server"; |
| 117 | const file = formData.get("photo"); |
| 118 | const caption = String(formData.get("caption") ?? "").trim(); |
| 119 | if (!(file instanceof File) || file.size === 0) return; |
| 120 | let url: string; |
| 121 | try { |
| 122 | url = await saveUpload(file, "image"); |
| 123 | } catch { |
| 124 | return; |
| 125 | } |
| 126 | await serverCaller().eventPhoto.submit({ |
| 127 | eventId: id, |
| 128 | url, |
| 129 | caption: caption || undefined, |
| 130 | }); |
| 131 | revalidatePath(`/city/${slug}/event/${id}`); |
| 132 | } |
| 133 | |
| 134 | async function reportEvent(formData: FormData) { |
| 135 | "use server"; |
| 136 | const reason = String(formData.get("reason") ?? "").trim(); |
| 137 | await serverCaller().flag.create({ |
| 138 | subjectType: "event", |
| 139 | subjectId: id, |
| 140 | reason: reason || undefined, |
| 141 | }); |
| 142 | revalidatePath(`/city/${slug}/event/${id}`); |
| 143 | } |
| 144 | |
| 145 | return ( |
| 146 | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> |
| 147 | <Link href={`/city/${slug}`} className="kicker hover:text-gold"> |
| 148 | ← {city.name} |
| 149 | </Link> |
| 150 | |
| 151 | <p className="kicker mt-10 text-ash"> |
| 152 | {(TYPE_LABEL[event.type] ?? event.type).toUpperCase()} · {fmtEventTime(event.startsAt, tz)} ·{" "} |
| 153 | {city.name} |
| 154 | </p> |
| 155 | <h1 className="display mt-3 text-6xl sm:text-7xl">{event.title}</h1> |
| 156 | |
| 157 | {event.description ? ( |
| 158 | <p className="serif mt-6 max-w-xl text-xl text-bone"> |
| 159 | {event.description} |
| 160 | </p> |
| 161 | ) : null} |
| 162 | |
| 163 | {event.flyerUrl ? ( |
| 164 | <figure className="mt-10"> |
| 165 | <img |
| 166 | src={event.flyerUrl} |
| 167 | alt={`${event.title} flyer`} |
| 168 | className="w-full border border-[var(--line)]" |
| 169 | /> |
| 170 | </figure> |
| 171 | ) : null} |
| 172 | |
| 173 | {event.externalUrl || event.ticketUrl || event.programUrl ? ( |
| 174 | <div className="mt-7 flex flex-wrap gap-4"> |
| 175 | {event.ticketUrl ? ( |
| 176 | <a |
| 177 | href={event.ticketUrl} |
| 178 | target="_blank" |
| 179 | rel="noopener noreferrer" |
| 180 | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" |
| 181 | > |
| 182 | Tickets → |
| 183 | </a> |
| 184 | ) : null} |
| 185 | {event.externalUrl ? ( |
| 186 | <a |
| 187 | href={event.externalUrl} |
| 188 | target="_blank" |
| 189 | rel="noopener noreferrer" |
| 190 | className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright" |
| 191 | > |
| 192 | Details → |
| 193 | </a> |
| 194 | ) : null} |
| 195 | {event.programUrl ? ( |
| 196 | <a |
| 197 | href={event.programUrl} |
| 198 | target="_blank" |
| 199 | rel="noopener noreferrer" |
| 200 | className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright" |
| 201 | > |
| 202 | Download the program (PDF) → |
| 203 | </a> |
| 204 | ) : null} |
| 205 | </div> |
| 206 | ) : null} |
| 207 | |
| 208 | {} |
| 209 | <section className="mt-14 border-t border-[var(--line)] pt-10"> |
| 210 | <div className="flex items-end justify-between"> |
| 211 | <h2 className="display text-3xl">Tickets</h2> |
| 212 | {featured ? ( |
| 213 | <span className="kicker text-gold">★ Featured</span> |
| 214 | ) : ( |
| 215 | <form action={boost}> |
| 216 | <button type="submit" className="kicker text-ash hover:text-gold"> |
| 217 | Boost this event → |
| 218 | </button> |
| 219 | </form> |
| 220 | )} |
| 221 | </div> |
| 222 | |
| 223 | {ticketTypes.length === 0 ? ( |
| 224 | <p className="serif mt-6 text-xl text-ash"> |
| 225 | No tickets yet. Add a ticket type below, or it's free at the door. |
| 226 | </p> |
| 227 | ) : ( |
| 228 | <ul className="mt-6 flex flex-col gap-3"> |
| 229 | {ticketTypes.map((t) => { |
| 230 | const left = t.capacity != null ? t.capacity - t.sold : null; |
| 231 | const soldOut = left != null && left <= 0; |
| 232 | return ( |
| 233 | <li |
| 234 | key={t.id} |
| 235 | className="flex items-center gap-4 border border-[var(--line)] px-5 py-4" |
| 236 | > |
| 237 | <div className="flex-1"> |
| 238 | <p className="display text-2xl">{t.name}</p> |
| 239 | <p className="kicker mt-1 text-ash"> |
| 240 | {money(t.priceCents)} |
| 241 | {left != null ? ` · ${left} LEFT` : ""} |
| 242 | </p> |
| 243 | </div> |
| 244 | {soldOut ? ( |
| 245 | <span className="kicker text-ash">Sold out</span> |
| 246 | ) : ( |
| 247 | <form action={reserve} className="flex items-center gap-2"> |
| 248 | <input type="hidden" name="ticketTypeId" value={t.id} /> |
| 249 | <input |
| 250 | type="number" |
| 251 | name="quantity" |
| 252 | defaultValue={1} |
| 253 | min={1} |
| 254 | max={Math.min(10, left ?? 10)} |
| 255 | className="kicker w-16 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold" |
| 256 | /> |
| 257 | <button |
| 258 | type="submit" |
| 259 | className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright" |
| 260 | > |
| 261 | Reserve |
| 262 | </button> |
| 263 | </form> |
| 264 | )} |
| 265 | </li> |
| 266 | ); |
| 267 | })} |
| 268 | </ul> |
| 269 | )} |
| 270 | |
| 271 | {} |
| 272 | <form action={addTicketType} className="mt-6 flex flex-wrap items-end gap-3"> |
| 273 | <input |
| 274 | name="name" |
| 275 | required |
| 276 | placeholder="Ticket name (e.g. General, VIP)" |
| 277 | className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" |
| 278 | /> |
| 279 | <input |
| 280 | type="number" |
| 281 | name="price" |
| 282 | step="1" |
| 283 | min="0" |
| 284 | placeholder="Price $" |
| 285 | className="kicker w-24 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold" |
| 286 | /> |
| 287 | <input |
| 288 | type="number" |
| 289 | name="capacity" |
| 290 | min="1" |
| 291 | placeholder="Qty (opt)" |
| 292 | className="kicker w-24 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold" |
| 293 | /> |
| 294 | <button |
| 295 | type="submit" |
| 296 | className="kicker border border-[var(--line)] px-5 py-2 text-gold hover:border-gold" |
| 297 | > |
| 298 | Add ticket |
| 299 | </button> |
| 300 | </form> |
| 301 | <p className="kicker mt-3 text-ash"> |
| 302 | Reserving is free while we turn on payments. |
| 303 | </p> |
| 304 | </section> |
| 305 | |
| 306 | {} |
| 307 | <section className="mt-16 border-t border-[var(--line)] pt-10"> |
| 308 | <div className="flex items-end justify-between"> |
| 309 | <h2 className="display text-3xl">Photos</h2> |
| 310 | <span className="kicker text-ash"> |
| 311 | {String(photos.length).padStart(2, "0")} in the album |
| 312 | </span> |
| 313 | </div> |
| 314 | |
| 315 | {photos.length === 0 ? ( |
| 316 | <p className="serif mt-6 text-xl text-ash"> |
| 317 | No photos in the album yet. Were you there? Send yours in below. |
| 318 | </p> |
| 319 | ) : ( |
| 320 | <div className="mt-6"> |
| 321 | <PhotoGallery photos={photos} /> |
| 322 | </div> |
| 323 | )} |
| 324 | |
| 325 | <form action={submitPhoto} className="mt-8 flex flex-wrap items-end gap-3"> |
| 326 | <label className="flex flex-1 flex-col gap-2"> |
| 327 | <span className="kicker text-ash">Send in your photos</span> |
| 328 | <input |
| 329 | type="file" |
| 330 | name="photo" |
| 331 | required |
| 332 | accept="image/jpeg,image/png,image/webp" |
| 333 | className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none file:mr-3 file:border-0 file:bg-gold file:px-3 file:py-1 file:text-stage" |
| 334 | /> |
| 335 | </label> |
| 336 | <input |
| 337 | name="caption" |
| 338 | maxLength={200} |
| 339 | placeholder="Caption (opt)" |
| 340 | className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" |
| 341 | /> |
| 342 | <button |
| 343 | type="submit" |
| 344 | className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright" |
| 345 | > |
| 346 | Submit |
| 347 | </button> |
| 348 | </form> |
| 349 | <p className="kicker mt-3 text-ash"> |
| 350 | JPEG, PNG, or WebP up to 8MB. Every photo is reviewed before it appears. |
| 351 | </p> |
| 352 | </section> |
| 353 | |
| 354 | {} |
| 355 | {eventVendors.length > 0 ? ( |
| 356 | <section className="mt-16 border-t border-[var(--line)] pt-10"> |
| 357 | <div className="flex items-end justify-between"> |
| 358 | <h2 className="display text-3xl">Vendors</h2> |
| 359 | <span className="kicker text-ash">On site at this event</span> |
| 360 | </div> |
| 361 | <ul className="mt-6 flex flex-col gap-3"> |
| 362 | {eventVendors.map((v) => ( |
| 363 | <li |
| 364 | key={v.id} |
| 365 | className="flex flex-wrap items-center gap-4 border border-[var(--line)] px-5 py-4" |
| 366 | > |
| 367 | <div className="flex-1"> |
| 368 | <p className="display text-2xl">{v.name}</p> |
| 369 | <p className="kicker mt-1 text-ash"> |
| 370 | {v.category ? v.category.toUpperCase() : "VENDOR"} |
| 371 | </p> |
| 372 | {v.description ? ( |
| 373 | <p className="serif mt-2 text-lg text-bone">{v.description}</p> |
| 374 | ) : null} |
| 375 | </div> |
| 376 | {v.linkUrl ? ( |
| 377 | <a |
| 378 | href={v.linkUrl} |
| 379 | target="_blank" |
| 380 | rel="noopener noreferrer" |
| 381 | className="kicker text-gold hover:text-gold-bright" |
| 382 | > |
| 383 | Visit → |
| 384 | </a> |
| 385 | ) : null} |
| 386 | </li> |
| 387 | ))} |
| 388 | </ul> |
| 389 | </section> |
| 390 | ) : null} |
| 391 | |
| 392 | {} |
| 393 | <section className="mt-16 border-t border-[var(--line)] pt-10"> |
| 394 | <div className="flex items-end justify-between"> |
| 395 | <h2 className="display text-3xl">The Crowd</h2> |
| 396 | <span className="kicker text-ash"> |
| 397 | {String(comments.length).padStart(2, "0")} comments |
| 398 | </span> |
| 399 | </div> |
| 400 | |
| 401 | <form action={addComment} className="mt-6 flex flex-col gap-3"> |
| 402 | <input |
| 403 | name="body" |
| 404 | required |
| 405 | placeholder="Say something about the set" |
| 406 | className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" |
| 407 | /> |
| 408 | <div className="mt-2"> |
| 409 | <button |
| 410 | type="submit" |
| 411 | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" |
| 412 | > |
| 413 | Post comment |
| 414 | </button> |
| 415 | </div> |
| 416 | </form> |
| 417 | |
| 418 | {comments.length === 0 ? ( |
| 419 | <p className="serif mt-8 text-xl text-ash"> |
| 420 | No comments yet. Be the first to speak on it. |
| 421 | </p> |
| 422 | ) : ( |
| 423 | <ul className="mt-8"> |
| 424 | {comments.map((c) => ( |
| 425 | <li |
| 426 | key={c.id} |
| 427 | className="border-b border-[var(--line)] py-6" |
| 428 | > |
| 429 | <p className="serif text-xl text-bone">{c.body}</p> |
| 430 | <p className="kicker mt-2 text-ash">{stamp(c.createdAt)}</p> |
| 431 | </li> |
| 432 | ))} |
| 433 | </ul> |
| 434 | )} |
| 435 | </section> |
| 436 | |
| 437 | {} |
| 438 | <section className="mt-16 border-t border-[var(--line)] pt-10"> |
| 439 | <div className="flex items-end justify-between"> |
| 440 | <h2 className="display text-3xl">Report</h2> |
| 441 | <span className="kicker text-ash">Flag for review</span> |
| 442 | </div> |
| 443 | <form action={reportEvent} className="mt-6 flex flex-wrap items-center gap-4"> |
| 444 | <input |
| 445 | name="reason" |
| 446 | placeholder="What's wrong with this listing? (optional)" |
| 447 | className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold" |
| 448 | /> |
| 449 | <button |
| 450 | type="submit" |
| 451 | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" |
| 452 | > |
| 453 | Report |
| 454 | </button> |
| 455 | </form> |
| 456 | </section> |
| 457 | <SiteFooter /> |
| 458 | </main> |
| 459 | ); |
| 460 | } |